Содержание статьи
В последние годы независимые от дистрибутива решения для установки пакетов набирают популярность: к 0install с его давней историей прибавились более новые Flatpak и Snap, да и в Steam игр для Linux становится больше.
Удобнее всего может быть обойтись без установки вообще — просто скачать файл, сделать его исполняемым с помощью chmod +x и запустить. В этой статье мы рассмотрим, почему это возможно и как это реализовать.
Двоичная совместимость в системах на основе Linux
Часто можно услышать, что в Linux плохо с двоичной совместимостью. При этом люди имеют в виду, что пакет из одной версии дистрибутива Linux порой невозможно поставить на другую, — и в этом они правы. Однако само утверждение о плохой двоичной совместимости версий Linux неверно.
Нужно вспомнить, что Linux — это только ядро, а вся операционная система состоит из ядра, разделяемых библиотек и программ. Библиотеки можно разделить на стандартные библиотеки языков (например, libc, libstd++) и сторонние библиотеки (например, GTK, Qt). Так вот, причиной сломанной двоичной совместимости, как правило, оказываются именно сторонние библиотеки.
Границы ABI, то есть места, где что-то может сломаться, зависят от метода компоновки (linking): статического или динамического.

При динамической компоновке стабильным должен оставаться ABI всех задействованных библиотек. При этом ABI ядра может меняться, если библиотеки это компенсируют. При статической компоновке важна только стабильность ABI ядра. Рассмотрим, как с этим в Linux.
Совместимость библиотек
ABI библиотеки — это набор символов, которые она экспортирует. Может ли он быть стабильным? Да, реализация формата ELF в Linux поддерживает версионирование символов. С помощью этого механизма библиотеки могут предоставлять несколько версий одной и той же функции с разными сигнатурами и поведением.
Очевидный недостаток этого подхода — невозможно удалить из библиотеки старый код без риска сломать совместимость. Каждый раз, когда сигнатура или поведение функции меняется, автор должен создать копию старой функции. Кроме того, указывать соответствие имен функций в исходном коде и версий символов этих функций в двоичном — обязанность разработчика.
Не у каждого разработчика есть время и возможность поддерживать совместимость таким образом. Кроме того, радикальные изменения во внутренностях библиотеки могут сделать этот подход совершенно непрактичным — придется, по сути, собирать один двоичный файл из нескольких версий исходного кода.
Именно из-за идеи сделать как можно больше библиотек общими для всех пакетов мы и не можем поставить пакет из Debian 9 на 10 или из CentOS 6 на 8 — сторонние библиотеки не входят в пакет, а их ABI не остается неизменным между версиями.
Однако разработчики GNU libc серьезно относятся к совместимости и версионируют все символы. Благодаря этому, если собрать программу на машине со старой версией glibc, она будет работать с более новыми (но не наоборот).
Совместимость версий ядра
ABI ядра представляет собой соглашение о системных вызовах, а также номера и порядок аргументов отдельных вызовов. К примеру, чтобы попросить ядро выполнить write — системный вызов для записи данных в файл, нужно поместить в один регистр процессора номер этого вызова, а в три других регистра — номер файлового дескриптора, указатель на буфер с данными и размер данных в байтах. Любая функция для записи в файл или вывода текста на экран (то есть записи в файлы stdout и stderr с дескрипторами 1 и 2 соответственно) — обертка к этому вызову.
Самый низкий уровень стандартной библиотеки любого языка — набор оберток к системным вызовам. Если изменится ABI ядра, перестанут работать все библиотеки. К счастью, в Linux такого не происходит — интерфейс системных вызовов исключительно стабилен. Линус Торвальдс строго следит за соблюдением совместимости и ругается на всех, кто ее пытается случайно или намеренно сломать.
Благодаря такой политике любая программа из времен ядра 2.6 будет нормально работать и на 5.x, если она не использует никакие разделяемые библиотеки.
Более того, новые программы могут работать на старых ядрах, если используют только старые системные вызовы.
А что в других системах?
Из распространенных свободных Unix-подобных систем Linux — единственная с такими гарантиями совместимости ABI ядра.
Именно поэтому для Linux существует множество альтернативных стандартных библиотек языка C (musl, dietlibc, uclibc, newlib...). По этой же причине FreeBSD реализует двоичную совместимость с Linux, но не наоборот — FreeBSD не дает гарантий стабильности ABI между релизами.
Поддержка версионирования символов в GNU libc тоже нетипична для реализаций стандартной библиотеки.
Таким образом, возможностей для двоичной совместимости в Linux больше, чем во многих других системах, — нужно только ими пользоваться.
Выводы
Из всего сказанного видно, что статическая сборка — залог совместимости с любой системой на основе Linux, причем и с более новыми ядрами, и с более старыми.
Альтернативный подход — AppImage тоже позволяет собрать все в один файл, но этот файл на деле представляет собой сжатый образ SquashFS с исполняемым файлом и динамическими библиотеками внутри. Увы, инструменты автоматического поиска и упаковки всех нужных библиотек капризны, и детальное рассмотрение этого подхода не поместится в рамки нашей статьи. Мы сосредоточимся на статической компоновке.
Практика
Для примера мы используем несложную программу, которая проверяет соответствие строки регулярному выражению. Мы соберем ее несколькими разными способами и посмотрим, как убедиться, что файл вышел действительно статический.
Собираем статические файлы с GNU libc
Для работы с регулярными выражениями мы используем библиотеку PCRE. Сохраним следующий код в файл match.c:
#include <stdio.h>
#include <string.h>
#include <pcre.h>
int main(int argc, char** argv) {
if(argc < 2) {
fprintf(stderr, "Usage: %s <regex> <string>\n", argv[0]);
exit(1);
}
char* regex = argv[1];
char* str = argv[2];
int offsets[1];
pcre *re;
int res;
const char *error;
int erroffset;
re = pcre_compile(regex, 0, &error, &erroffset, NULL);
res = pcre_exec(re, NULL, str, strlen(str), 0, 0, offsets, 1);
return res;
}
Программа принимает регулярное выражение и произвольную строку. Если строка соответствует этому выражению, программа завершается с кодом 0.
$ ./match '^\d+[a-z]?$' '212850a' && echo yes
yes
Для начала соберем программу самым «обычным» образом.
$ gcc -o match -lpcre ./match.c
$ ldd ./match
linux-vdso.so.1 (0x00007ffc0ec5d000)
libpcre.so.1 => /lib64/libpcre.so.1 (0x00007fe700e24000)
libc.so.6 => /lib64/libc.so.6 (0x00007fe700c5a000)
libpthread.so.0 => /lib64/libpthread.so.0 (0x00007fe700c38000)
/lib64/ld-linux-x86-64.so.2 (0x00007fe700ed5000)
По умолчанию GCC использует динамическую компоновку, поэтому в выводе команды ldd мы видим зависимость от libpcre.so.
Если ты хочешь просто проверить, динамический файл или статический, достаточно и утилиты file.
$ file ./match
./match: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=9e2c84368501419dc6da681aa0f0520950ad896a, for GNU/Linux 3.2.0, not stripped
Теперь перейдем к статической сборке. Для этого нужно убедиться, что в твоей системе есть статическая версия PCRE. Традиционное расширение файлов статических библиотек — .a. В системах на основе Debian большинство пакетов с библиотеками содержат и статическую, и динамическую версии. В системах семейства Red Hat статические версии библиотек часто живут в отдельных пакетах. На моей Fedora перед сборкой этого примера нужно выполнить sudo dnf install glibc-static pcre-static.
Тонкий момент — при статической сборке порядок следования аргументов становится важным и файл с программой должен идти до всех библиотек. Наивное добавление опции -static к прошлой команде вызовет ошибку.
$ gcc -o match -static -lpcre ./match.c
/usr/bin/ld: /tmp/ccdiyqgc.o: in function 'main':
match.c:(.text+0x72): undefined reference to 'pcre_compile'
/usr/bin/ld: match.c:(.text+0xae): undefined reference to 'pcre_exec'
collect2: error: ld returned 1 exit status
Однако и команда gcc ./match.c -o match -static -lpcre будет не совсем верной. При динамической компоновке GCC (вернее, GNU ld, которую он вызвал) сам разобрался, что libpcre зависит от libpthread (библиотеки POSIX threads). Теперь же нам придется указать ее вручную. Но с верным набором и порядком опций мы наконец получим статический исполняемый файл.
$ gcc ./match.c -o match -static -lpcre -lpthread
$ file ./match
./match: ELF 64-bit LSB executable, x86-64, version 1 (GNU/Linux), statically linked, BuildID[sha1]=3f816f2c426e417b27de3ac2cf2cfc28ad989889, for GNU/Linux 3.2.0, not stripped, too many notes (256)
$ ldd ./match
not a dynamic executable
Статическая GNU libc и NSS
В статической версии GNU libc есть серьезный подводный камень — она не совсем статическая. Дело в том, что библиотеки для разрешения сетевых имен — libnss и ее компоненты — подгружаются динамически, даже если сам файл собран статически.
Для программы из нашего примера это не проблема, поскольку она вовсе не использует сеть. Не будет это проблемой и для сетевых программ, если запускать их на системах, где есть GNU libc такой же или более свежей версии.
Однако во встраиваемых системах и образах initrd этот нюанс придется учитывать. Я сам узнал об этом, когда собрал образ initrd для загрузки по сети через PXE и обнаружил, что Wget из BusyBox работает только по IP и не способен разрешать имена хостов.
Если GNU libc в целевой системе нет, то нужно использовать альтернативную стандартную библиотеку языка C.
Альтернативные библиотеки
Одна из самых популярных альтернативных библиотек — musl. Ее использует Alpine Linux. Кроме того, некоторые компиляторы других языков предоставляют опцию связывания с ней вместо GNU libc. Такая опция есть в Rust и OCaml.
И в Debian, и в Fedora с версии 32 присутствует пакет musl-gcc. Он предоставляет обертку к GCC, которая автоматически использует musl вместо GNU libc. Использование не отличается от обычного GCC. Простейший пример:
musl-gcc -static -o hello ./hello.c
Основная сложность подхода в том, что использовать двоичные пакеты из дистрибутива с такой оберткой не выйдет, если только сам дистрибутив не использует ее по умолчанию. Придется собрать библиотеки для нее, и некоторые могут так и не собраться. Зато и исполняемые файлы на самом деле не зависят ни от чего, кроме ABI ядра.
Заключение
Статическая компоновка, безусловно, усложняет сборку, но при этом обеспечивает совместимость и с будущими, и с прошлыми версиями ядра Linux. Так что, если ты хочешь распространять свою программу в собранном виде, эти усилия окупятся.










